package de.kardass.jsmatep.in;

import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;

import de.kardass.jsmatep.generator.java.JavaGeneratorConstants;
import de.kardass.jsmatep.generator.java.MethodNameGenerator;
import de.kardass.jsmatep.in.adjust.CsvRecordAdjuster;
import de.kardass.jsmatep.in.adjust.FixedLengthRecordAdjuster;
import de.kardass.jsmatep.in.adjust.RecordAdjuster;
import de.kardass.jsmatep.in.adjust.VariableLengthRecordAdjuster;
import de.kardass.jsmatep.in.reader.CsvRecordReader;
import de.kardass.jsmatep.in.reader.FixedLengthRecordReader;
import de.kardass.jsmatep.in.reader.RecordReader;
import de.kardass.jsmatep.in.reader.VariableLengthRecordReader;
import de.kardass.jsmatep.in.validation.CsvRecordValidator;
import de.kardass.jsmatep.in.validation.FixedLengthRecordValidator;
import de.kardass.jsmatep.in.validation.RecordValidator;
import de.kardass.jsmatep.in.validation.VariableLengthRecordValidator;
import de.kardass.jsmatep.schema.jSmaTePImportConfig.FieldType;
import de.kardass.jsmatep.schema.jSmaTePImportConfig.RecordImportConfigType;

/**
 * Mapper to 'translate' a text data record into a value object.
 *
 * @author Manfred Kardass (manfred@kardass.de)
 *
 */
public class RecordToValueObjectMapper<T> {
	
	/** The used date formatter */
	private static SimpleDateFormat dateFormat = new SimpleDateFormat();

	/** Cache for all methods in a given value object */
	private final HashMap<String, Method> methodNameMappings;
	
	/**	The import configuration used by this mapper */
	private RecordImportConfigType importConfig;
	
	/**	The record reader used by this mapper depending on a given configuration */
	private final RecordReader recordReader;
	
	/**	The record validator used by this mapper depending on a given configuration */
	private final RecordValidator recordValidator;

	/**	The record validator used by this mapper depending on a given configuration */
	private final RecordAdjuster recordAdjuster;

	/**
	 * @param importConfig  The configuration to use for the mapper
	 */
	public RecordToValueObjectMapper(RecordImportConfigType importConfig) {
		this.importConfig = importConfig;
		this.methodNameMappings = new HashMap<String, Method>();
		// init
		if (importConfig.getType().getCsv() != null) {
			recordReader = new CsvRecordReader(importConfig);
			recordValidator = new CsvRecordValidator(importConfig);
			recordAdjuster = new CsvRecordAdjuster(importConfig);
		} else if (importConfig.getType().getFixedLength() != null) {
			recordReader = new FixedLengthRecordReader(importConfig);
			recordValidator = new FixedLengthRecordValidator(importConfig);
			recordAdjuster = new FixedLengthRecordAdjuster(importConfig);
		} else if (importConfig.getType().getVariableLength() != null) {
			recordReader = new VariableLengthRecordReader(importConfig);
			recordValidator = new VariableLengthRecordValidator(importConfig);
			recordAdjuster = new VariableLengthRecordAdjuster(importConfig);
		} else {
			throw new IllegalArgumentException("Unknown record type: " + importConfig.getType());
		}
	}

	/**
	 * Maps a record to a value object using a given configuration.
	 * 
	 * @param adjustedRecord
	 * @param valueObject
	 * @param importConfig
	 * @return
	 */
	public T mapRecordToValueObject(final String record, T valueObject) {
		final String adjustedRecord = getRecordAdjuster().adjustRecordVsConfig(record);
		getRecordValidator().validateRecordVsConfig(adjustedRecord);
		String aValueString;
		try {
			final FieldType[] fieldTypes = importConfig.getFields().getFieldArray();
			for (FieldType aFieldConfig : fieldTypes) {
				aValueString = getRecordReader().readField(aFieldConfig, adjustedRecord);
				valueObject = setValue(aValueString, valueObject, aFieldConfig);
			}
			return valueObject;
		} catch (Exception e) {
			throw new RecordMappingException(adjustedRecord, e);
		}
	}

	/**
	 * Set the value into the field using a given config.
	 * 
	 * @param valueString
	 * @param valueObject
	 * @param fieldConfig
	 * @return
	 */
	private T setValue(String valueString, T valueObject, FieldType fieldConfig) {
		try {
			final String fieldName = ConfigUtil.getFieldName(fieldConfig);
			Method setMethod = null;
			Object settableObject = null;
			
			// normal (direkt) settable property 
			if (fieldName.indexOf('.') == -1) {
				String setterMethodName = MethodNameGenerator.generateSetterName(fieldName);
				setMethod = getMethod(setterMethodName, valueObject.getClass());
				settableObject = valueObject;
				
			// sub-property (indirekt) settable property 
			} else {
				final int lastIndex = fieldName.lastIndexOf('.');
				final String getterPart = fieldName.substring(0, lastIndex); // getter of the sub-property
				final String setterPart = fieldName.substring(lastIndex+1); // sub-property's setter for the value
				final String getterMethodName = MethodNameGenerator.generateGetterName(getterPart);
				final Method getMethod = valueObject.getClass().getMethod(getterMethodName, (Class[]) null);
				// get the object that will hold the value
				settableObject = getMethod.invoke(valueObject, (Object[]) null);
				// if the value holder object is null, try to instantiate the object an set in the value object
				if (settableObject == null) {
					final Constructor<?> constr = getMethod.getReturnType().getConstructor((Class[]) null);
					// find the default constructor
					settableObject = constr.newInstance((Object[]) null);
					final String setterNameSubProperty = MethodNameGenerator.generateSetterName(getterPart);
					// get the value object's setter to set the new instantiated sub-property
					final Method setterSubProperty = valueObject.getClass().getMethod(setterNameSubProperty, settableObject.getClass());
					// set the sub property in the value object
					setterSubProperty.invoke(valueObject, settableObject);
				}
				// remember the set method to set the value
				setMethod = getMethod(MethodNameGenerator.generateSetterName(setterPart), settableObject.getClass());
			}
			if (setMethod == null) {
				throw new NoSuchMethodException("No setter in " + valueObject.getClass() + " for configured field: " + fieldConfig);
			}
			final Class<?> setMethodParamType = setMethod.getParameterTypes()[0];
			final Object value = createValue(valueString, setMethodParamType, fieldConfig);
			if (value != null) {
				setMethod.invoke(settableObject, value);
			}
		} catch (Exception e) {
			throw new FieldMappingException(valueString, e);
		}
		return valueObject;
	}

	/**
	 * Creates a value (Integer, Date etc.) from the given value using the parameter in the 
	 * set-method. The field config is used to get the format parameter for special types 
	 * like e.g. {@link java.util.Date}  , {@link java.math.BigDecimal}.
	 * 
	 * @param valueString
	 * @param paramType
	 * @param fieldConfig
	 * @return
	 */
	private Object createValue(String valueString, Class<?> paramType, FieldType fieldConfig) 
			throws Exception {
		try {
			Object result;
			final String paramTypeName = paramType.getName();
			if (paramTypeName.equals(String.class.getName())) {
				result = isStringNullOrNullValue(valueString, fieldConfig) ? null : valueString;
			} else if (paramTypeName.equals(int.class.getName()) || paramTypeName.equals(Integer.class.getName())) {
				result = isStringNullOrNullValueOrZeroLength(valueString, fieldConfig) ? null : Integer.parseInt(valueString.trim());
			} else if (paramTypeName.equals(long.class.getName()) || paramTypeName.equals(Long.class.getName())) {
				result = isStringNullOrNullValueOrZeroLength(valueString, fieldConfig) ? null : Long.parseLong(valueString.trim());
			} else if (paramTypeName.equals(float.class.getName()) || paramTypeName.equals(Float.class.getName())) {
				result = isStringNullOrNullValueOrZeroLength(valueString, fieldConfig) ? null : Float.parseFloat(valueString.trim());
			} else if (paramTypeName.equals(double.class.getName()) || paramTypeName.equals(Double.class.getName())) {
				result = isStringNullOrNullValueOrZeroLength(valueString, fieldConfig) ? null : Double.parseDouble(valueString.trim());
			} else if (paramTypeName.equals(char.class.getName()) || paramTypeName.equals(Character.class.getName())) {
				result = isStringNullOrNullValueOrZeroLength(valueString, fieldConfig) ? null : valueString.charAt(0);
			} else if (paramTypeName.equals(Date.class.getName())) {
				if (isStringNullOrNullValueOrZeroLength(valueString, fieldConfig)) {
					result = null;
				} else {
					assertFormatExists(fieldConfig);
					dateFormat.applyPattern(fieldConfig.getFormat());
					result = dateFormat.parse(valueString);
				}
			} else if (paramTypeName.equals(BigDecimal.class.getName()) || paramTypeName.equals("Decimal")) {
				if (isStringNullOrNullValueOrZeroLength(valueString, fieldConfig)) {
					result = null;
				} else {
					if (formatExists(fieldConfig)) {
						int positions = getPositionAfterDecimalPoint(fieldConfig.getFormat());
						result = new BigDecimal(valueString.trim()).movePointLeft(positions);
					} else {
						result = new BigDecimal(valueString.trim());
					}
				}
			} else {
				throw new RecordMappingException("Method parameter type [" + paramType + "] is not supported!");
			}
			return result;
		} catch (Exception e) {
			throw new RuntimeException("Error while processing value [" + valueString 
					+ "], parameter type [" + paramType 
					+ "] for field configuration: " + fieldConfig, e);
		}
	}
	
	/**
	 * Checks whether the format parameter is set in the configuration.
	 * 
	 * @param fieldConfig
	 * @return
	 */
	private static boolean formatExists(final FieldType fieldConfig) {
		return fieldConfig.getFormat() != null && fieldConfig.getFormat().trim().length() > 0;
	}

	/**
	 * Checks if a string is null or equal to a given null-value.
	 * 
	 * @param str
	 * @return
	 */
	private static boolean isStringNullOrNullValue(String str, FieldType fieldConfig) {
		boolean isEmpty = (str == null);
		final String nullValue = fieldConfig.getNullValue();
		if (nullValue != null) {
			isEmpty |= nullValue.equals(str);
		}
		return isEmpty;
	}

	/**
	 * Checks if a string is null or equal to a given null-value or it's length is 0.
	 * 
	 * @param str
	 * @return
	 */
	private static boolean isStringNullOrNullValueOrZeroLength(String str, FieldType fieldConfig) {
		return isStringNullOrNullValue(str, fieldConfig) || str.trim().length() == 0;
	}

	/**
	 * Return e.g 3 if a format string for a BigDecimal is '4,3'.
	 * 
	 * @param format
	 * @return
	 */
	private int getPositionAfterDecimalPoint(String format) {
		int result = 0;
		int lastIdx = format.lastIndexOf(',');
		if (lastIdx >= 0) {
			result = Integer.parseInt(format.substring(lastIdx + 1));
		}
		return result;
	}

	/**
	 * Ensures that the format is specified in the configuration.
	 * 
	 * @param fieldConfig
	 */
	private static void assertFormatExists(FieldType fieldConfig) {
		if (!formatExists(fieldConfig)) {
			throw new IllegalArgumentException("Missing format for: " + fieldConfig);
		}
	}
	
	/**
	 * Return the method by it's name of the class.
	 *  
	 * @param methodName
	 * @param theClass
	 * @return
	 */
	private Method getMethod(String methodName, Class<?> theClass) {
		final String key = generateMethodKey(theClass, methodName);
		if (!methodNameMappings.containsKey(key)) {
			addMethodNameMapping(theClass);
		}
		return methodNameMappings.get(key );
	}
	
	/**
	 * Creates the cache for all setter in the class.
	 * 
	 * @param theClass
	 */
	private void addMethodNameMapping(Class<?> theClass) {
		Method[] methods = theClass.getMethods();
		String methodName;
		for (Method aMethod : methods) {
			methodName = aMethod.getName();
			if (methodName.startsWith(JavaGeneratorConstants.SET)) {
				methodNameMappings.put(
						generateMethodKey(theClass, methodName), aMethod);
			}
		}
	}

	private String generateMethodKey(Class<?> theClass, String methodName) {
		return theClass.getName() + '.' + methodName;
	}

	public RecordImportConfigType getImportConfig() {
		return importConfig;
	}

	public void setImportConfig(RecordImportConfigType importConfig) {
		this.importConfig = importConfig;
	}

	public RecordReader getRecordReader() {
		return recordReader;
	}

	public RecordValidator getRecordValidator() {
		return recordValidator;
	}

	public RecordAdjuster getRecordAdjuster() {
		return recordAdjuster;
	}

}
